● Zion Boggan
repos/Stepping App/apps/web/src/app/city/[slug]/live/page.tsx
zionboggan.com ↗
193 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { SiteFooter } from "@/components/site-footer";
3
import { notFound } from "next/navigation";
4
import { revalidatePath } from "next/cache";
5
import { serverCaller } from "@/lib/trpc/server";
6
 
7
export const dynamic = "force-dynamic";
8
 
9
function youtubeId(url: string): string | null {
10
  try {
11
    const u = new URL(url);
12
    const host = u.hostname.replace(/^www\./, "");
13
    if (host === "youtu.be") {
14
      const id = u.pathname.slice(1);
15
      return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
16
    }
17
    if (host === "youtube.com" || host === "m.youtube.com") {
18
      if (u.pathname === "/watch") {
19
        const id = u.searchParams.get("v");
20
        return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
21
      }
22
      const m = u.pathname.match(/^\/(?:embed|shorts|live)\/([a-zA-Z0-9_-]{11})/);
23
      if (m) return m[1];
24
    }
25
    return null;
26
  } catch {
27
    return null;
28
  }
29
}
30
 
31
export default async function LivePage({
32
  params,
33
}: {
34
  params: Promise<{ slug: string }>;
35
}) {
36
  const { slug } = await params;
37
  const caller = serverCaller();
38
  const city = await caller.city.bySlug({ slug });
39
  if (!city) notFound();
40
 
41
  const events = await caller.event.list({ cityId: city.id });
42
  const live = events.filter((e) => e.isLive);
43
 
44
  async function goLive(formData: FormData) {
45
    "use server";
46
    const id = String(formData.get("eventId") ?? "");
47
    const liveUrl = String(formData.get("liveUrl") ?? "").trim();
48
    if (!id || !liveUrl) return;
49
    await serverCaller().event.setLive({ id, isLive: true, liveUrl });
50
    revalidatePath(`/city/${slug}/live`);
51
    revalidatePath(`/city/${slug}`);
52
  }
53
 
54
  async function endLive(formData: FormData) {
55
    "use server";
56
    const id = String(formData.get("eventId") ?? "");
57
    if (!id) return;
58
    await serverCaller().event.setLive({ id, isLive: false });
59
    revalidatePath(`/city/${slug}/live`);
60
    revalidatePath(`/city/${slug}`);
61
  }
62
 
63
  return (
64
    <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
65
      <Link href={`/city/${slug}`} className="kicker hover:text-gold">
66
        ← {city.name}
67
      </Link>
68
 
69
      <p className="kicker mt-10 flex items-center gap-3 text-gold">
70
        <span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-gold" />
71
        Live · {city.metroLabel}
72
      </p>
73
      <h1 className="display mt-3 text-7xl sm:text-8xl">Live</h1>
74
      <p className="serif mt-5 max-w-xl text-xl text-ash">
75
        Catch a set live from {city.name}, or put your own set on. Drop a
76
        YouTube or Facebook live link and it shows up here.
77
      </p>
78
 
79
      {}
80
      <section className="mt-16 border-t border-[var(--line)] pt-10">
81
        <div className="flex items-end justify-between">
82
          <h2 className="display text-3xl">On Now</h2>
83
          <span className="kicker text-ash">
84
            {String(live.length).padStart(2, "0")} live
85
          </span>
86
        </div>
87
 
88
        {live.length === 0 ? (
89
          <p className="serif mt-8 text-xl text-ash">
90
            Nobody is live right now. Start a stream below and the set goes on
91
            here.
92
          </p>
93
        ) : (
94
          <ul className="mt-8 flex flex-col gap-12">
95
            {live.map((e) => {
96
              const ytId = e.liveUrl ? youtubeId(e.liveUrl) : null;
97
              return (
98
                <li key={e.id}>
99
                  <div className="flex items-end justify-between gap-5">
100
                    <h3 className="display text-2xl sm:text-3xl">{e.title}</h3>
101
                    <span className="kicker flex items-center gap-2 text-gold">
102
                      <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-gold" />
103
                      LIVE
104
                    </span>
105
                  </div>
106
 
107
                  {ytId ? (
108
                    <div className="mt-5 aspect-video w-full overflow-hidden border border-[var(--line)]">
109
                      <iframe
110
                        src={`https://www.youtube-nocookie.com/embed/${ytId}`}
111
                        title={e.title}
112
                        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
113
                        allowFullScreen
114
                        className="h-full w-full"
115
                      />
116
                    </div>
117
                  ) : e.liveUrl ? (
118
                    <a
119
                      href={e.liveUrl}
120
                      target="_blank"
121
                      rel="noopener noreferrer"
122
                      className="kicker mt-5 inline-block bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
123
                    >
124
                      Watch live →
125
                    </a>
126
                  ) : null}
127
 
128
                  <form action={endLive} className="mt-4">
129
                    <input type="hidden" name="eventId" value={e.id} />
130
                    <button
131
                      type="submit"
132
                      className="kicker text-ash hover:text-bone"
133
                    >
134
                      End stream
135
                    </button>
136
                  </form>
137
                </li>
138
              );
139
            })}
140
          </ul>
141
        )}
142
      </section>
143
 
144
      {}
145
      <section className="mt-16 border-t border-[var(--line)] pt-10">
146
        <div className="flex items-end justify-between">
147
          <h2 className="display text-3xl">Go Live</h2>
148
          <span className="kicker">Put a set on</span>
149
        </div>
150
 
151
        {events.length === 0 ? (
152
          <p className="serif mt-8 text-xl text-ash">
153
            Post a set on the city page first, then come back to take it live.
154
          </p>
155
        ) : (
156
          <form action={goLive} className="mt-6 flex flex-col gap-3">
157
            <select
158
              name="eventId"
159
              required
160
              defaultValue=""
161
              className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
162
            >
163
              <option value="" disabled className="bg-stage">
164
                CHOOSE A SET
165
              </option>
166
              {events.map((e) => (
167
                <option key={e.id} value={e.id} className="bg-stage">
168
                  {e.title.toUpperCase()}
169
                </option>
170
              ))}
171
            </select>
172
            <input
173
              name="liveUrl"
174
              required
175
              type="url"
176
              placeholder="Live stream URL (YouTube or Facebook live)"
177
              className="serif border-b border-[var(--line)] bg-transparent py-3 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
178
            />
179
            <div className="mt-2">
180
              <button
181
                type="submit"
182
                className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
183
              >
184
                Go live
185
              </button>
186
            </div>
187
          </form>
188
        )}
189
      </section>
190
      <SiteFooter />
191
    </main>
192
  );
193
}